Skip to content

feat(ui): add Mermaid diagram visualization in markdown#21497

Closed
callmeYe wants to merge 10 commits into
anomalyco:devfrom
callmeYe:feat/mermaid-upstream
Closed

feat(ui): add Mermaid diagram visualization in markdown#21497
callmeYe wants to merge 10 commits into
anomalyco:devfrom
callmeYe:feat/mermaid-upstream

Conversation

@callmeYe

@callmeYe callmeYe commented Apr 8, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Adds interactive Mermaid diagram visualization for ```mermaid code blocks in the markdown renderer, inspired by Vercel's @streamdown/mermaid.

Currently, mermaid code blocks are rendered as plain text (Shiki doesn't recognize the mermaid language and falls back to text). This PR intercepts mermaid code blocks in both markedShiki highlight and highlightCodeBlocks (native parser path), outputs a placeholder <div data-component="mermaid-diagram">, then asynchronously renders them as interactive SVG diagrams after morphdom DOM updates.

The mermaid library (~3MB) is lazy-loaded on first use to avoid impacting initial bundle size.

Interactive features:

  • Zoom: Scroll wheel + button controls (0.5x–3x range, 0.1 step)
  • Pan: Pointer drag with setPointerCapture for reliable tracking
  • Fullscreen: Appends a fixed overlay to document.body, re-renders SVG via mermaid.render() with a fresh ID to avoid DOM ID/CSS selector conflicts with the inline copy. Closes via Escape key or X button.
  • Download: Dropdown with SVG, PNG (5x canvas for high-res), and .mmd source export
  • Copy source: Clipboard write with 2-second visual feedback
  • Error handling: Failed renders show an error bar above the preserved source code

Streaming compatibility: markdown-stream.ts already splits incomplete code fences, so incomplete mermaid blocks display as raw code until the fence closes, then auto-render. Rendered diagrams are preserved across morphdom updates via onBeforeElUpdated returning false for data-mermaid-rendered elements.

Key fix: Mermaid outputs width="100%" on SVGs, which collapses to 0px in a pure flex layout (the fullscreen viewport). fixSvgSizing() replaces percentage width with the actual viewBox dimensions and constrains with max-width: 90vw / max-height: 85vh.

How did you verify your code works?

  • Tested locally with multiple mermaid diagram types (flowchart, sequence, ER, state, pie, gantt)
  • Verified streaming behavior: incomplete code fences show raw code, completed fences auto-render
  • Tested all interactive controls: zoom in/out/reset, drag pan, fullscreen open/close (Escape + X button + backdrop click), download SVG/PNG/MMD, copy source
  • Verified fullscreen renders correctly (no black rectangles or blank SVGs) by re-rendering with a fresh mermaid ID
  • Confirmed regular code blocks are unaffected
  • Typecheck passes (bun run typecheck in packages/ui)

Screenshots / recordings

N/A — this is best tested interactively. Send any message containing a ```mermaid code block to see the rendered diagram with hover controls.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

🤖 Generated with Claude Code

@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Apr 8, 2026
Add interactive Mermaid diagram rendering for ```mermaid code blocks,
inspired by Vercel's @streamdown/mermaid package.

Features:
- Lazy-load mermaid library (~3MB) on first use
- Auto-detect dark/light theme for diagram rendering
- Interactive controls: zoom (0.5x-3x), pan (drag), fullscreen,
  download (SVG/PNG/source), copy source
- Fullscreen mode re-renders SVG to avoid DOM ID conflicts
- Fix SVG width="100%" collapse in flex layouts via viewBox sizing
- Streaming-compatible: incomplete code fences show raw code
- Error handling with source fallback display

Integration:
- Intercept mermaid language in markedShiki highlight and
  highlightCodeBlocks (native parser path)
- Skip mermaid blocks in ensureCodeWrapper (decorate)
- Preserve rendered diagrams across morphdom updates
- Async post-processing after morphdom

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@callmeYe callmeYe force-pushed the feat/mermaid-upstream branch from 533e0c6 to 10e6c50 Compare April 8, 2026 09:57
@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Apr 8, 2026
@github-actions

github-actions Bot commented Apr 8, 2026

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

callmeYe and others added 2 commits April 9, 2026 14:06
Merged latest dev into feat/mermaid-upstream and regenerated bun.lock
via `bun install` to resolve the lockfile conflict.
@callmeYe

callmeYe commented Apr 9, 2026

Copy link
Copy Markdown
Author

/review

callmeYe and others added 6 commits April 9, 2026 15:29
The previous bun install runs on this branch picked up a local
~/.npmrc that points to the Alibaba intranet mirror
(registry.anpm.alibaba-inc.com), which baked private registry URLs
into ~3257 package entries in bun.lock. That makes the lockfile
unreproducible for anyone outside the corp network and breaks
upstream CI.

Reset bun.lock to github/dev's baseline and re-ran
`BUN_CONFIG_REGISTRY=https://registry.npmjs.org/ bun install` so
the new mermaid dependency tree is added against the public
registry. The only remaining diff vs dev is the mermaid packages
plus the version bumps brought in by the merge from dev.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@callmeYe

Copy link
Copy Markdown
Author

Hey @adamdotdevin, could you review this PR when you get a chance? It adds Mermaid diagram rendering for packages/ui/. Thanks!

@rekram1-node

Copy link
Copy Markdown
Collaborator

Automated PR Cleanup

Thank you for contributing to opencode.

Due to the high volume of PRs from users and AI agents, we periodically close older PRs using automated criteria so maintainers can focus review time on the most active and community-supported contributions.

This PR was closed because it matched the following cleanup criteria:

  • The PR was created more than 1 month ago
  • The PR had fewer than 2 positive reactions
  • Positive reactions are counted as thumbs-up, heart, celebration, or rocket reactions on the PR

PRs created within the last month are not affected by this cleanup.

If you believe this PR was closed incorrectly, or if you are still actively working on it, please leave a comment explaining why it should be reopened. A maintainer can review and reopen it if appropriate.

Thanks again for taking the time to contribute.

@rasperepodvipodvert

Copy link
Copy Markdown

Auto-closed by the cleanup bot, not on review. This addresses mermaid in chat markdown (#23688 is a different path — markdown preview mode). Issue #3366 has 24 +1s. Could a maintainer reopen?

@rasperepodvipodvert

Copy link
Copy Markdown

Ran this branch locally against streaming markdown from a chat model and hit three correctness issues that don't show up in static-file tests. Filing them together since they share root causes (treating each container as "render-once" and missing concurrency guards).

1. Re-render is skipped after the first chunk (streaming use case)

renderMermaidDiagrams in mermaid.ts selects only containers without data-mermaid-rendered:

const containers = root.querySelectorAll(
  '[data-component="mermaid-diagram"]:not([data-mermaid-rendered])',
)

…and both buildRenderedDOM and buildErrorDOM set data-mermaid-rendered="true" on completion. That means once a container has been processed — even with a parse error — subsequent passes never look at it again.

Repro: the model streams a mermaid block in chunks. Chunk 1 arrives as graph TD\n A (incomplete), mermaid.render throws, buildErrorDOM runs and locks the container. Chunk 2 completes the source to a valid graph TD\n A --> B, but the container is now flagged and we keep showing the error bar forever. Same problem if chunk 1 is valid but chunk 2 changes the diagram structure — the new source is ignored.

Suggested fix: key the skip on source identity, not a boolean flag. Hash the source and store it on two attributes:

const MERMAID_SOURCE_ATTR = "data-mermaid-source-hash"
const MERMAID_LAST_GOOD_HASH = "data-mermaid-last-good-hash"

// Skip if last successful render matches current source.
// Retry if only a failed attempt is recorded — source may now be complete.
if (container.getAttribute(MERMAID_LAST_GOOD_HASH) === sourceHash) continue
if (container.getAttribute(MERMAID_SOURCE_ATTR) === sourceHash) continue
container.setAttribute(MERMAID_SOURCE_ATTR, sourceHash)  // mark attempt
// ...render...
container.setAttribute(MERMAID_LAST_GOOD_HASH, sourceHash)  // mark success

On failure, the existing rendered DOM (from the previous good chunk) stays visible while the model keeps streaming.

2. mermaid.render races between concurrent <Markdown> instances

renderMermaidDiagrams is awaited per-container inside a single call, but nothing prevents two <Markdown> components from calling it simultaneously. Mermaid v11 uses a shared offscreen container for layout measurement and is not concurrency-safe — concurrent renders produce orphan <svg id="mermaid-..."> nodes in document.body and occasionally cross-pollinate output between blocks.

Repro: open a session that has mermaid blocks across multiple historical messages. Every <Markdown> mounts roughly simultaneously and each fires off renderMermaidDiagrams(container). With 3+ blocks it reproduces reliably on Chrome.

Suggested fix: serialise every render through a module-level promise chain:

let mermaidRenderChain: Promise<unknown> = Promise.resolve()

export function renderMermaidDiagrams(root: HTMLElement): Promise<void> {
  const task = mermaidRenderChain.then(() => renderMermaidDiagramsImpl(root))
  mermaidRenderChain = task.catch(() => undefined)
  return task
}

Strictly sequential renders, fully backwards-compatible call signature.

3. Document-level click listeners leak per diagram

In setupControlActions (mermaid.ts:552):

document.addEventListener("click", (e) => {
  if (downloadMenu.style.display === "none") return
  ...
})

This is attached every time a diagram is rendered and never removed. Each new mermaid block in a chat session adds another listener that stays alive for the lifetime of the page. The container.addEventListener("click", ...) a few lines above has the same issue. Re-mounting the <Markdown> (route change, search, etc.) compounds it.

Suggested fix: scope listeners per-render and clean them up. Either store an AbortController per container and trigger .abort() on re-render/unmount:

const ac = new AbortController()
container.addEventListener("click", ..., { signal: ac.signal })
document.addEventListener("click", ..., { signal: ac.signal })
container.setAttribute("data-mermaid-ac", "")  // marker for cleanup
// later, when re-rendering or unmounting: ac.abort()

…or move the outside-click logic to a single delegated listener installed once at module init.

Bonus (minor)

detectTheme() reads the theme synchronously on loadMermaid()'s first call and caches the result for the lifetime of mermaidPromise. If the user toggles theme later, new diagrams still use the original theme. Not urgent — most users don't toggle mid-session — but worth a TODO.


Happy to send a follow-up PR with the three fixes above, @callmeYe — against this branch once reopened, or as a fresh PR after this one merges. The downstream patches are running cleanly in our fork.

CI on this PR was green at the time of auto-close. The bot's "fewer than 2 reactions in a month" trigger is unrelated to code quality — left a +1 and a reopen request earlier.

@callmeYe

Copy link
Copy Markdown
Author

If there's still appetite from the maintainer side to land this, I'm happy to keep iterating @rekram1-node @rasperepodvipodvert

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants